Исследование по разработке стратегии взаимодействия с клиентами сети фитнес-центраов¶
Задачи исследования - на основе данных о посетителях:
- спрогнозировать их отток и проанализировать основные признаки, наиболее сильно на него влияющие;
- сформировать типичные портреты клиентов;
- разработать рекомендации по повышению качества работы с клиентами.
Импортируем необходимые библиотеки.
import pandas as pd
import phik
import matplotlib.pyplot as plt
import plotly.express as px
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, precision_score, recall_score
from sklearn.preprocessing import StandardScaler
from scipy.cluster.hierarchy import dendrogram, linkage
from sklearn.cluster import KMeans
Загрузим данные и ознакомимся с ними.
local_path = r'C:\Практикум\Учебные проекты\13 Основы машинного обучения\\'
cloud_path = r'Путь к облачному хранилищу'
def get_data(file_name):
try:
data = pd.read_csv(local_path + file_name)
print('Датасет {0} загружен локально.'.format(file_name))
except:
data = pd.read_csv(cloud_path + file_name)
print('Датасет {0} загружен из облака.'.format(file_name))
return data
data = get_data('gym_churn.csv')
Датасет gym_churn.csv загружен локально.
#data.info()
#data.head()
Все данные (в том числе, категориальные) представлены в числовом формате, пропуски отсутствуют.
data.duplicated().sum()
0
Явных дубликатов нет.
Предобработка не требуется.
Посмотрим на средние значения параметров для оттекших и оставшихся клиентов.
data.groupby('Churn').agg('mean')
| gender | Near_Location | Partner | Promo_friends | Phone | Contract_period | Group_visits | Age | Avg_additional_charges_total | Month_to_end_contract | Lifetime | Avg_class_frequency_total | Avg_class_frequency_current_month | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Churn | |||||||||||||
| 0 | 0.510037 | 0.873086 | 0.534195 | 0.353522 | 0.903709 | 5.747193 | 0.464103 | 29.976523 | 158.445715 | 5.283089 | 4.711807 | 2.024876 | 2.027882 |
| 1 | 0.510839 | 0.768143 | 0.355325 | 0.183789 | 0.902922 | 1.728558 | 0.268615 | 26.989632 | 115.082899 | 1.662582 | 0.990575 | 1.474995 | 1.044546 |
Некоторые значения (такие как - пол, наличие контактного телефона, etc.) практически равны. Некоторые (например, длительность абонемента) заметно отличаются. Очевидно, что факторы влияют на отток клиентов по-разному.
Построим графики распределения каждого параметра для обеих групп.
print("Распределение параметров для оставшихся (Churn = 0) и ушедших (Churn = 1) клиентов")
for feature in data.columns:
fig = px.histogram(data, x=feature, color='Churn', barmode='group', width=600, height=300)
fig.show()
Распределение параметров для оставшихся (Churn = 0) и ушедших (Churn = 1) клиентов
- Распределение (соотношение) признаков 'gender' и 'Phone' в группах оттекших и оставшихся примерно одинаково. Вероятно, эти признаки не влияют на отток.
- Среди оставшихся заметно больше доля живущих/работающих рядом; работающих в организации-партнере; пришедших по рекомендации друзей; посещающих групповые занятия.
- Ощутима разница между группами по признаку 'Contract_period'. Соотношение оставшихся/ушедших для 1-месячного договора: 4/3, 6-месячного: 7/1, 12-месячного: 37/1. Видимо, срок контракта сильно влияет на целевой признак.
- Распределение возраста в обеих группах похоже на нормальное. Аномалий не видно. Для ушедших распределение смещено влево, в сторону молодости.
- Распределение параметра 'Month_to_end_contract' логично согласуется с распределением 'Contract_period'.
- Гистограмма признака 'Lifetime' похожа на распределение Пуассона. Аномалий не видно. У ушедших этот параметр ощутимо ниже.
- Распределения 'Avg_class_frequency_total' и 'Avg_class_frequency_current_month' похожи на смещенные влево нормальные. Для второго признака степень смещения ушедших заметнее. Видимо, перед тем как уйти, они начинают ходить реже. Виден пик в районе нуля - те, кто купили абонемент и не ходят. Любопытно, что на первом графике доля неходящих оставшихся больше, а на втором - меньше неходящих и ушедших.
Построим тепловую карту корреляций для категориальных переменных (phik_matrix).
fig, ax = plt.subplots(figsize=(8,7))
sns.heatmap(data[['gender',
'Near_Location',
'Partner',
'Promo_friends',
'Phone',
'Group_visits',
'Churn']].phik_matrix(), annot=True, linewidth=.1) \
.set(title='Тепловая карта корреляций для категориальных переменных')
plt.show()
interval columns not set, guessing: ['gender', 'Near_Location', 'Partner', 'Promo_friends', 'Phone', 'Group_visits', 'Churn']
- Работа в компании-партнере и использование промокода при записи показывают умеренную корреляцию между собой.
- Ни один из параметров не показывает даже умеренной связи с целевой переменной.
- Пол и наличие контактных данных вообще никак ни с чем не коррелирут.
Построим тепловую карту корреляций Пирсона/Спирмана для количественных переменных.
fig, ax = plt.subplots(figsize=(9,8))
sns.heatmap(data[['Age',
'Lifetime',
'Contract_period',
'Month_to_end_contract',
'Avg_class_frequency_total',
'Avg_class_frequency_current_month',
'Avg_additional_charges_total',
'Churn']].corr(), annot=True, linewidth=.1, cmap='coolwarm') \
.set(title='Тепловая карта корреляций для количественных переменных')
plt.show()
Очень сильную положительную корреляцию между собой демонстрируют:
- длительность абонемента и срок до его окончания;
- средняя частота посещений за все время и за последний месяц.
Довольно логично.
Остальные факторы слабо (или очень слабо) положительно коррелируют друг с другом.
Некоторые параметры умеренно отрицательно коррелируют с целевой переменной. Вероятность оттока тем меньше чем:
- старше клиент (понимание приходит с опытом);
- больше времени прошло с момента первого обращения в фитнес-центр (постоянные клиенты, без иронии - такие постоянные!);
- длиннее абонемент и
- дольше до его окончания (заплатил - ходи!);
- чаще клиент посещал занятия в последний месяц (бросают поэтапно, а не в один момент).
Средняя посещаемость за весь срок и суммарная побочная выручка не сильно влияют на отток (-0.25 и -0.2).
Обучим несколько моделей прогнозирования отткока клиентов и сравним результаты их предсказательной способности, используя метрики Accuracy, Precision и Recall. Обочим модели:
- методами логистической регрессии и случайного леса;
- на сырых и очищенных данных;
- на сырых и стандартизированных данных.
(всего - восемь комбинаций)
Объявим функцию,
- разбивающую данные на обучающую и тестовую выборки,
- стандартизирующую данные,
- обучающую модель
- и получающую интересующие нас метрики.
def get_models_n_metrics(data, scaled, method, name):
# Разобъем данные на обучающую и валидационную выборки в соотношении 3/1:
X = data.drop('Churn', axis=1)
y = data['Churn']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=0)
# Стандартизируем данные:
if scaled == True:
ss = StandardScaler()
X_train = ss.fit_transform(X_train)
X_test = ss.transform(X_test)
# Обучим модель:
model = method
model.fit(X_train, y_train)
prediction = model.predict(X_test)
# Получим метрики Accuracy, Precision и Recall:
accuracy = accuracy_score(y_test, prediction)
precision = precision_score (y_test, prediction)
recall = recall_score (y_test, prediction)
result = pd.DataFrame(data={'accuracy': accuracy, 'precision': precision, 'recall': recall}, index=name)
return result
Создадим датафрейм, из которого исключим сильно коррелирующие между собой признаки, чтобы избавиться от мультиколлениарности, а также - параметры, не влияющие на целевую переменную.
r_data = data
c_data = data.drop(['Month_to_end_contract', 'Avg_class_frequency_total', 'gender', 'Phone'], axis=1)
Получим таблицу с показателями метрик для каждого из вариантов моделей.
metrics = pd.DataFrame(columns = ['accuracy', 'precision', 'recall'])
for key, value in {'Логистическая регрессия, ': LogisticRegression(solver='liblinear'),
'Случайный лес, ': RandomForestClassifier()}.items():
name_1 = key
method = value
for key, value in {'сырые ': r_data, 'очищенные ': c_data}.items():
name_2 = name_1 + key
data = value
for key, value in {'стандартизированные данные': True, 'нестандартизированные данные': False}.items():
name = name_2 + key
scaled = value
metrics = metrics.append(get_models_n_metrics(data, scaled, method, [name]))
# Добавим поле со средним значением:
metrics['avg_metrics'] = metrics.mean(axis=1)
Построим тепловую карту сравнительной таблицы.
fig, ax = plt.subplots(figsize=(8,6))
sns.heatmap(metrics, annot=True, linewidth=.1, cmap='Greens') \
.set(title='Сравнение метрик для обученных моделей')
plt.show()
- Очистка данных негативно отразилась на всех метриках.
- Стандартизация почти не повлияла на результаты.
- Метод логистической регрессии показал себя немного лучше по количеству правильных ответов, точности и полноте.
- Все модели продемонстрировали сравнимые хорошие показатели.
Результаты теста могут различаться при ином разбиении на выборки.
Построим дендрограмму, которая поможет определить количество кластеров, осуществим кластеризацию, и постараемся выделить ключевые признаки для каждого кластера.
Исключим никак ни с чем не коррелирующие параметры.
data = r_data.drop(['gender', 'Phone'], axis=1)
Обучим модель на генеральной выборке без столбца с оттоком и стандартизируем данные.
X = data.drop('Churn', axis=1)
scaler = StandardScaler()
X_st = scaler.fit_transform(X)
Построим дендрограмму.
linked = linkage(X_st, method = 'ward')
plt.figure(figsize=(15, 15))
dendrogram(linked, orientation='top')
plt.title('Иерархическая кластеризация для сети фитнес-центров')
plt.show()
Основываясь на уровне ветвения и размере, кажется целесообразным выделить 5 кластеров, а не 3, как предлагает алгоритм.
km = KMeans(n_clusters=5, random_state=0)
labels = km.fit_predict(X_st)
Добавим к датафрейму столбец с номером кластера и посмотрим на сгруппированные данные.
data['claster'] = labels
data.groupby('claster').agg('mean').sort_values('Churn').round(2)
| Near_Location | Partner | Promo_friends | Contract_period | Group_visits | Age | Avg_additional_charges_total | Month_to_end_contract | Lifetime | Avg_class_frequency_total | Avg_class_frequency_current_month | Churn | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| claster | ||||||||||||
| 3 | 0.94 | 0.74 | 0.49 | 11.91 | 0.55 | 29.91 | 164.41 | 10.91 | 4.68 | 1.99 | 1.98 | 0.02 |
| 1 | 0.97 | 0.27 | 0.09 | 2.93 | 0.47 | 30.25 | 163.54 | 2.69 | 5.23 | 2.90 | 2.90 | 0.06 |
| 0 | 1.00 | 0.82 | 1.00 | 3.14 | 0.45 | 29.22 | 141.79 | 2.90 | 3.73 | 1.75 | 1.64 | 0.25 |
| 4 | 0.00 | 0.47 | 0.08 | 2.21 | 0.22 | 28.47 | 133.48 | 2.08 | 2.77 | 1.65 | 1.46 | 0.45 |
| 2 | 1.00 | 0.24 | 0.02 | 1.96 | 0.33 | 28.19 | 130.88 | 1.88 | 2.38 | 1.29 | 1.05 | 0.53 |
Значения в столбцах отличаются, можно выделить характерные черты для каждого кластера.
Исключение - возраст. Исключим столбец 'Age' и пересоберем кластеры.
data_1 = r_data.drop(['gender', 'Phone', 'Age'], axis=1)
X_1 = data_1.drop('Churn', axis=1)
X_st_1 = scaler.fit_transform(X_1)
km = KMeans(n_clusters=5, random_state=0)
labels_1 = km.fit_predict(X_st_1)
data_1['claster'] = labels_1
clasters = data_1.groupby('claster').agg('mean').sort_values('Churn')
clasters.round(2)
| Near_Location | Partner | Promo_friends | Contract_period | Group_visits | Avg_additional_charges_total | Month_to_end_contract | Lifetime | Avg_class_frequency_total | Avg_class_frequency_current_month | Churn | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| claster | |||||||||||
| 1 | 0.94 | 0.74 | 0.49 | 11.90 | 0.55 | 164.87 | 10.90 | 4.70 | 1.99 | 1.98 | 0.02 |
| 4 | 0.97 | 0.25 | 0.08 | 2.89 | 0.47 | 161.47 | 2.65 | 5.13 | 2.89 | 2.89 | 0.07 |
| 2 | 1.00 | 0.80 | 1.00 | 3.12 | 0.45 | 140.85 | 2.89 | 3.66 | 1.75 | 1.64 | 0.25 |
| 0 | 0.00 | 0.47 | 0.08 | 2.18 | 0.21 | 133.56 | 2.04 | 2.77 | 1.66 | 1.47 | 0.45 |
| 3 | 1.00 | 0.25 | 0.01 | 1.95 | 0.33 | 131.69 | 1.86 | 2.41 | 1.27 | 1.03 | 0.52 |
Средние значения параметров не сильно изменились (хотя, заранее мы этого не знали и шаг кажется оправданным).
Для упрощения анализа ранжируем распределение значений признаков от 1-го до 10-ти, где единице соответствует минимальное значение и построим тепловую карту.
n = 10
group_names = list(range(1, n+1))
for column in clasters.columns:
clasters[column] = pd.cut(clasters[column], bins = n, labels=group_names)
clasters = clasters.astype('int')
fig, ax = plt.subplots(figsize=(15,5))
sns.heatmap(clasters, linewidth=.1, annot=True) \
.set(title='Распределение признаков по кластерам')
plt.show()
Расположим кластеры в порядке увеличения вероятности оттока и выделим характерные черты:
- Кластер "1": С большой вероятностью - сотрудник компании-партнера и/или использовавший промокод при оплате первого абонемента, с очень долгим контрактом. Постоянный клиент. Ходит два раза в неделю, не редко посещает групповые занятия. Вероятность оттока - около 2%.
- Кластер "4": Характерная черта - максимальная среди всех кластеров частота посещений - 3 раза в неделю, при сравнительно небольшом сроке контракта. Также - постоянный клиент. Вероятность оттока - 7%.
- Кластер "2": Использовавший промокод сотрудник компании-партнера. Почти все стальные показатели близки к средним. Вероятность оттока - 25%.
- Кластер "0": Далеко живущие. Ходят полтора раза в неделю, и этот показатель падает. Почти не посещают групповых занятий. Вроятность оттока 45%.
- Кластер "3": Живут рядом, но ходят редко и имеют краткосрочные контракты. Возможно - зашли попробовать, но не очень понравилось. Уходят в более чем половине случаев.
В ходе предварительного анализа данных выяснилось, что предобработка не требуется.
Построение тепловых карт корреляций показало неравнозначное влияние факторов на целевую переменную, а также, на взаимосвязь параметров между собой.
В качестве метода разбиения на обучающую и валидационную выборки был выбран случайный метод, т.к. данные не содержат временных рядов.
Были обучены и протестированы две модели прогнозирования оттока клиентов. Использовался алгоритм логической регрессии и случайный лес. Обе модели показали высокое значение метрик Accuracy, Precision и Recall.
Удалось разбить клиентов на группы с помощью алгоритма кластеризации. Ниже представлены маркетинговые рекомендации, как по каждой группе, так и общие.
- "1". Группа клиентов с долгосрочным контрактом не требует внимания прямо сейчас, но, возможно, есть смысл постоянно поддерживать их контракт долгим. Например, предлагая льготные условия пролонгации. И чем заранее, тем льготнее.
- "4". Данная группа, вероятно, не требует дополнительных трат маркетингового бюджета, однако, имеет смысл провести дополнительное исследование и выявить факторы, которые сильнее влияют на отток в этой группе.
- "2". За исключением короткого абонемента и довольно большой вероятностью оттока, эта группа близка по характеристикам к первой. Возможно, жесткая, но действенная мера - ограничение минимального срока договора для сотрудников компаний-партнеров - могла бы способствовать переходу этого типа клиентов в первый кластер.
- "0". Вряд ли что-либо возможно сделать с основным показателем для этой группы - расстоянием от работы или дома до фитнес-центра, однако, редкость посещения групповых занятий (что, скорее - следствие) подвластна влиянию. Возможно, если предложить особые условия посещения таких занятий, клиенты из этой группы найдут себе фитнес-друзей, будут чаще и с большим интересом ходить в фитнес-центр, что может снизить показатель оттока.
- "3". Пришли попробовать, но не понравилось. Нужно сделать так, чтобы понравилось. В отличие от предыдущей группы, на эту, кажется, проще повлиять маркетинговым способом. Можно детально поработать с такими клиентами, провести, например, анкетирование, и выяснить, чем их можно заинтересовать.
Для всех групп: кажется, самым влиятельным, и, в то же время, подверженным влиянию фактором является наличие действующего долгосрочного договора. Имеет смысл детально проработать стратегию мотивирования клиентов к заключению такого типа контрактов.